diff --git a/Dockerfile b/Dockerfile index 8f8098f..7931aae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.12-bookworm COPY entrypoint.py /entrypoint.py +COPY cofactr_cogs /cofactr_cogs RUN pip install requests diff --git a/README.md b/README.md index cd3f63f..373659d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Cofactr COGS -Generate cost of goods sold (COGS) using Cofactr. +Generate cost of goods sold (COGS) in AllSpice Actions using Cofactr. + +This uses the Cofactr API. See the [Cofactr API docs](https://help.cofactr.com/en/articles/8868930-cofactr-component-cloud-api-documentation) for more information. ## Usage @@ -11,7 +13,11 @@ Add the following step to your actions: uses: https://hub.allspice.io/Actions/cofactr-cogs@v1 with: bom_file: bom.csv + bom_part_number_column: Part Number + bom_manufacturer_column: Manufacturer + bom_quantity_column: Quantity quantities: "1,10,100,1000" + search_strategy: mpn_sku_mfr client_id: YOUR_COFACTR_CLIENT_ID api_key: ${{ secrets.COFACTR_API_KEY }} output_file: cogs.csv diff --git a/action.yml b/action.yml index 835b247..a43b6ca 100644 --- a/action.yml +++ b/action.yml @@ -12,13 +12,24 @@ inputs: default: "1,10,100,1000" bom_part_number_column: description: > - The name of the part number column in the BOM file. Defaults to 'Part - Number'. - default: Part Number + The name of the part number column in the BOM file. You must provide + this. + default: '' + bom_manufacturer_column: + description: > + The name of the manufacturer column in the BOM file. Defaults to ''. If + you use a search strategy that uses manufacturer, you must provide this. + default: '' bom_quantity_column: description: > - The name of the quantity column in the BOM file. Defaults to 'Quantity'. - default: Quantity + The name of the quantity column in the BOM file. You must provide this. + default: '' + search_strategy: + description: > + The Cofactr search strategy. Can be: "mpn_sku_mfr" or "fuzzy" (uses mpn). + Defaults to "mpn_sku_mfr". See Cofactr API documentation for more + information on search strategies. + default: mpn_sku_mfr output_file: description: > The path to the output file. Defaults to stdout, i.e. printing to the @@ -30,6 +41,9 @@ inputs: client_id: description: "Cofactr Client ID" required: true + log_level: + description: Set log level for debugging + default: info runs: using: "docker" image: "Dockerfile" @@ -38,10 +52,16 @@ runs: - "${{ inputs.quantities }}" - "--bom-part-number-column" - ${{ inputs.bom_part_number_column }} + - "--bom-manufacturer-column" + - ${{ inputs.bom_manufacturer_column }} - "--bom-quantity-column" - ${{ inputs.bom_quantity_column }} + - "--search-strategy" + - ${{ inputs.search_strategy }} - "--output-file" - ${{ inputs.output_file }} + - "--log-level" + - ${{ inputs.log_level }} - ${{ inputs.bom_file }} env: ALLSPICE_AUTH_TOKEN: ${{ github.token }} diff --git a/cofactr_cogs/__init__.py b/cofactr_cogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cofactr_cogs/api.py b/cofactr_cogs/api.py new file mode 100644 index 0000000..39cc650 --- /dev/null +++ b/cofactr_cogs/api.py @@ -0,0 +1,111 @@ +from dataclasses import dataclass +from enum import Enum +import os +import sys + +import requests + + +class SearchStrategy(Enum): + MPN_SKU_MFR = "mpn_sku_mfr" + FUZZY = "fuzzy" + + def to_query_value(self) -> str: + # Cofactr's default search strategy is designed for search results. + if self == SearchStrategy.FUZZY: + return "default" + return self.value + + def query_needs_manufacturer(self) -> bool: + return self == SearchStrategy.MPN_SKU_MFR + + +@dataclass +class PartPrices: + cofactr_id: str + prices: dict[int, float] + + +def fetch_price_for_part( + part_number: str, manufacturer: str, search_strategy: SearchStrategy +) -> PartPrices | None: + """ + Get the price of a component per n units. + + The return value of this function is a mapping of number of units to the + price in dollars per unit if you purchase that many units. For example:: + + { + 1: 0.5, + 10: 0.45, + 500: 0.4, + 100: 0.35, + } + + In this case, the price per unit is 0.5, the price per unit if you buy 10 + or more is 0.45, the price per unit if you buy 50 or more is 0.4, and so on. + Your breakpoints can be any positive integer. + + The implementation of this function depends on the API you are using to get + pricing data. This is an example implementation that uses the cofactr API, + and will not work unless you have a cofactr API key. You will need to + replace this function with your own implementation if you use some other + API, such as Octopart or TrustedParts. You have access to the `requests` + python library to perform HTTP requests. + + :param part_number: A part number by which to search for the component. + :returns: A mapping of price breakpoints to the price at that breakpoint. + """ + + if part_number.startswith("NOTAPART"): + return None + + api_key = os.environ.get("COFACTR_API_KEY") + client_id = os.environ.get("COFACTR_CLIENT_ID") + if api_key is None or client_id is None: + raise ValueError( + "Please set the COFACTR_API_KEY and COFACTR_CLIENT_ID environment variables" + ) + + query = part_number + if search_strategy.query_needs_manufacturer() and manufacturer: + query += f" {manufacturer}" + + search_response = requests.get( + "https://graph.cofactr.com/products/", + headers={ + "X-API-KEY": api_key, + "X-CLIENT-ID": client_id, + }, + params={ + "q": query, + "search_strategy": search_strategy.to_query_value(), + "schema": "product-offers-v0", + "external": "true", + "limit": "1", + }, + ) + + if search_response.status_code != 200: + print( + f"Warning: Received status code {search_response.status_code} for {part_number} {manufacturer}", + file=sys.stderr, + ) + return None + + search_results = search_response.json() + try: + reference_prices = search_results.get("data", [])[0].get("reference_prices") + except IndexError: + print( + f"Warning: No results found for {part_number} {manufacturer}", + file=sys.stderr, + ) + return None + + prices = {int(price["quantity"]): float(price["price"]) for price in reference_prices} + + return PartPrices( + cofactr_id=search_results["data"][0]["id"], + prices=prices, + ) diff --git a/cofactr_cogs/cli.py b/cofactr_cogs/cli.py new file mode 100644 index 0000000..eccf0e0 --- /dev/null +++ b/cofactr_cogs/cli.py @@ -0,0 +1,198 @@ +# Compute the Cost of Goods Sold for a BOM. +# +# This script doesn't depend on py-allspice, but it requires a BOM CSV file to +# run. You can use https://github.com/AllSpiceIO/generate-bom to generate a BOM +# CSV. +from argparse import ArgumentParser +from contextlib import ExitStack +import csv +import sys + +from cofactr_cogs.api import fetch_price_for_part, PartPrices, SearchStrategy + + +def main() -> None: + parser = ArgumentParser() + + parser.add_argument( + "bom_file", + help="The path to the BOM file.", + ) + parser.add_argument( + "--quantities", + help=( + "A comma-separated list of quantities of PCBs to compute the COGS " + + "for. Defaults to '%(default)s'." + ), + default="1,10,100,1000", + ) + parser.add_argument( + "--bom-part-number-column", + help="The name of the part number column in the BOM file. You must provide this.", + default="", + ) + parser.add_argument( + "--bom-manufacturer-column", + help="The name of the manufacturer column in the BOM file. Defaults to '%(default)s'. If " + + "you use a search strategy that uses manufacturer, you must provide this.", + default="", + ) + parser.add_argument( + "--bom-quantity-column", + help="The name of the quantity column in the BOM file. You must provide this.", + default="", + ) + parser.add_argument( + "--search-strategy", + help="The Cofactr search strategy. Can be: mpn_sku_mfr or fuzzy (uses mpn). " + + "Defaults to '%(default)s'. The API also supports mpn_exact and mpn_exact_mfr, " + + "but they are not recommended.", + default="mpn_sku_mfr", + ) + parser.add_argument( + "--output-file", + help="The path to the output file. Defaults to stdout, i.e. printing to the console.", + ) + parser.add_argument( + "--log-level", + help="The log level to use. Defaults to '%(default)s'.", + default="info", + ) + + args = parser.parse_args() + + quantities = [int(quantity) for quantity in args.quantities.split(",")] + + part_number_column = args.bom_part_number_column + if not part_number_column: + raise ValueError( + "BOM part number column needs to be specified. Please set bom_part_number_column." + ) + + manufacturer_column = args.bom_manufacturer_column + quantity_column = args.bom_quantity_column + if not quantity_column: + raise ValueError( + "BOM quantity column needs to be specified. Please set bom_quantity_column." + ) + + search_strategy = SearchStrategy(args.search_strategy) + + with open(args.bom_file, "r") as bom_file: + bom_csv = csv.DictReader(bom_file) + + parts = [ + part for part in bom_csv if part[part_number_column] and part[part_number_column] != "" + ] + + print(f"Computing COGS for {len(parts)} parts", file=sys.stderr) + print(f"Fetching prices for {len(parts)} parts", file=sys.stderr) + + prices_for_parts = {} + + use_mfr = bool(manufacturer_column) + if not use_mfr and search_strategy.query_needs_manufacturer(): + raise ValueError( + "Search strategy requires manufacturer, but no BOM manufacturer column was provided. Please set bom_manufacturer_column." + ) + + if args.log_level.lower() == "debug": + print(f"Using part number column: {part_number_column!r}", file=sys.stderr) + print(f"Using manufacturer column: {manufacturer_column!r}", file=sys.stderr) + print(f"Using use_mfr: {use_mfr!r}", file=sys.stderr) + print(f"Using quantity column: {quantity_column!r}", file=sys.stderr) + print(f"Using search strategy: {search_strategy!r}", file=sys.stderr) + + for part in parts: + part_number = part[part_number_column] + manufacturer = part[manufacturer_column] if use_mfr else "" + part_prices = fetch_price_for_part(part_number, manufacturer, search_strategy) + if part_prices is not None and len(part_prices.prices) > 0: + prices_for_parts[(part_number, manufacturer)] = part_prices + + print(f"Found prices for {len(prices_for_parts)} parts", file=sys.stderr) + + if len(prices_for_parts) == 0: + print("No prices found for any parts", file=sys.stderr) + sys.exit(1) + + # The number of columns that the output should have. + expected_columns = (4 if use_mfr else 3) + 2 * len(quantities) + + headers = ["Part Number"] + if use_mfr: + headers.append("Manufacturer") + headers.append("Cofactr ID") + headers.append("Quantity") + + for quantity in quantities: + headers.append(f"Per Unit at {quantity}") + headers.append(f"Total at {quantity}") + + assert len(headers) == expected_columns + + rows = [] + totals: dict[int, float] = {quantity: 0 for quantity in quantities} + + for part in parts: + part_number = part[part_number_column] + manufacturer = part[manufacturer_column] if use_mfr else "" + part_quantity = int(part[quantity_column]) + + part_prices = prices_for_parts.get((part_number, manufacturer)) + cofactr_id = part_prices.cofactr_id if part_prices else None + + current_row = [part_number] + if use_mfr: + current_row.append(manufacturer) + current_row.append(cofactr_id) + current_row.append(part_quantity) + + for quantity in quantities: + breakpoints = price_breakpoints(part_prices, quantity) + if part_prices and breakpoints: + largest_breakpoint_less_than_qty = max(breakpoints) + price_at_breakpoint = part_prices.prices[largest_breakpoint_less_than_qty] + current_row.append(price_at_breakpoint) + total_for_part_at_quantity = price_at_breakpoint * part_quantity + current_row.append(total_for_part_at_quantity) + totals[quantity] += total_for_part_at_quantity + else: + current_row.append(None) + current_row.append(None) + + assert len(current_row) == expected_columns + rows.append(current_row) + + with ExitStack() as stack: + if args.output_file: + file = stack.enter_context(open(args.output_file, "w")) + writer = csv.writer(file) + else: + writer = csv.writer(sys.stdout) + + writer.writerow(headers) + writer.writerows(rows) + + totals_row = ["Totals", None, None] + if use_mfr: + totals_row.append(None) + for quantity in quantities: + totals_row.append(None) + totals_row.append(str(totals[quantity])) + + assert len(totals_row) == expected_columns + writer.writerow(totals_row) + + print("Computed COGS", file=sys.stderr) + + +def price_breakpoints(part_prices: PartPrices | None, quantity: int) -> list[int] | None: + if part_prices is None: + return None + breakpoints = [breakpoint for breakpoint in part_prices.prices.keys() if breakpoint <= quantity] + return breakpoints if len(breakpoints) > 0 else None + + +if __name__ == "__main__": + main() diff --git a/entrypoint.py b/entrypoint.py index 26952b3..e099f9d 100755 --- a/entrypoint.py +++ b/entrypoint.py @@ -1,205 +1,5 @@ #! /usr/bin/env python3 - -# Compute the Cost of Goods Sold for a BOM. -# -# This script doesn't depend on py-allspice, but it requires a BOM CSV file to -# run. You can use https://github.com/AllSpiceIO/generate-bom to generate a BOM -# CSV. - -from argparse import ArgumentParser -from contextlib import ExitStack -import csv -import os -import sys -import requests - - -def fetch_price_for_part(part_number: str) -> dict[int, float]: - """ - Get the price of a component per n units. - - The return value of this function is a mapping of number of units to the - price in dollars per unit if you purchase that many units. For example:: - - { - 1: 0.5, - 10: 0.45, - 500: 0.4, - 100: 0.35, - } - - In this case, the price per unit is 0.5, the price per unit if you buy 10 - or more is 0.45, the price per unit if you buy 50 or more is 0.4, and so on. - Your breakpoints can be any positive integer. - - The implementation of this function depends on the API you are using to get - pricing data. This is an example implementation that uses the cofactr API, - and will not work unless you have a cofactr API key. You will need to - replace this function with your own implementation if you use some other - API, such as Octopart or TrustedParts. You have access to the `requests` - python library to perform HTTP requests. - - :param part_number: A part number by which to search for the component. - :returns: A mapping of price breakpoints to the price at that breakpoint. - """ - - if part_number.startswith("NOTAPART"): - return {} - - api_key = os.environ.get("COFACTR_API_KEY") - client_id = os.environ.get("COFACTR_CLIENT_ID") - if api_key is None or client_id is None: - raise ValueError( - "Please set the COFACTR_API_KEY and COFACTR_CLIENT_ID environment variables" - ) - - search_response = requests.get( - "https://graph.cofactr.com/products/", - headers={ - "X-API-KEY": api_key, - "X-CLIENT-ID": client_id, - }, - params={ - "q": part_number, - "schema": "product-offers-v0", - "external": "true", - "limit": "1", - }, - ) - - if search_response.status_code != 200: - print( - f"Warning: Received status code {search_response.status_code} for {part_number}", - file=sys.stderr, - ) - return {} - - search_results = search_response.json() - try: - reference_prices = search_results.get("data", [])[0].get("reference_prices") - except IndexError: - print( - f"Warning: No results found for {part_number}", - file=sys.stderr, - ) - return {} - - prices = {int(price["quantity"]): float(price["price"]) for price in reference_prices} - - return prices - +from cofactr_cogs.cli import main if __name__ == "__main__": - parser = ArgumentParser() - - parser.add_argument( - "bom_file", - help="The path to the BOM file.", - ) - parser.add_argument( - "--quantities", - help=( - "A comma-separated list of quantities of PCBs to compute the COGS " - + "for. Defaults to '%(default)s'." - ), - default="1,10,100,1000", - ) - parser.add_argument( - "--bom-part-number-column", - help="The name of the part number column in the BOM file. Defaults to '%(default)s'.", - default="Part Number", - ) - parser.add_argument( - "--bom-quantity-column", - help="The name of the quantity column in the BOM file. Defaults to '%(default)s'.", - default="Quantity", - ) - parser.add_argument( - "--output-file", - help="The path to the output file. Defaults to stdout, i.e. printing to the console.", - ) - - args = parser.parse_args() - - quantities = [int(quantity) for quantity in args.quantities.split(",")] - - part_number_column = args.bom_part_number_column - quantity_column = args.bom_quantity_column - - with open(args.bom_file, "r") as bom_file: - bom_csv = csv.DictReader(bom_file) - - parts = [ - part for part in bom_csv if part[part_number_column] and part[part_number_column] != "" - ] - - print(f"Computing COGS for {len(parts)} parts", file=sys.stderr) - print(f"Fetching prices for {len(parts)} parts", file=sys.stderr) - - prices_for_parts = {} - - for part in parts: - part_number = part[part_number_column] - prices = fetch_price_for_part(part_number) - if prices and len(prices) > 0: - prices_for_parts[part_number] = prices - - print(f"Found prices for {len(prices_for_parts)} parts", file=sys.stderr) - - if len(prices_for_parts) == 0: - print("No prices found for any parts", file=sys.stderr) - sys.exit(1) - - headers = [ - "Part Number", - "Quantity", - ] - - for quantity in quantities: - headers.append(f"Per Unit at {quantity}") - headers.append(f"Total at {quantity}") - - rows = [] - totals: dict[int, float] = {quantity: 0 for quantity in quantities} - - for part in parts: - part_number = part[part_number_column] - part_quantity = int(part[quantity_column]) - - current_row = [part_number, part_quantity] - - for quantity in quantities: - try: - prices = prices_for_parts[part_number] - largest_breakpoint_less_than_qty = max( - [breakpoint for breakpoint in prices.keys() if breakpoint <= quantity] - ) - price_at_breakpoint = prices[largest_breakpoint_less_than_qty] - current_row.append(price_at_breakpoint) - total_for_part_at_quantity = price_at_breakpoint * part_quantity - current_row.append(total_for_part_at_quantity) - totals[quantity] += total_for_part_at_quantity - except (ValueError, KeyError): - current_row.append(None) - current_row.append(None) - - rows.append(current_row) - - with ExitStack() as stack: - if args.output_file: - file = stack.enter_context(open(args.output_file, "w")) - writer = csv.writer(file) - else: - writer = csv.writer(sys.stdout) - - writer.writerow(headers) - writer.writerows(rows) - - totals_row = ["Totals", None] - for quantity in quantities: - totals_row.append(None) - totals_row.append(str(totals[quantity])) - - writer.writerow(totals_row) - - print("Computed COGS", file=sys.stderr) + main()